系列背景:我在用 Rust 打造私有自持的 AI agent 網格 spectyn-mesh。
本篇是 Day 04 的實錄下集:上集(13 章圖文整理版 + 實錄精選)
見前一篇〈Day 04:一個不准猜的 CLI〉;這裡收錄其餘全部段落,
原文一字未改,按當天時序排列。母規則同前:
任何檢查必須先示範它會紅才算數。
Q3 原本的題目是「serve 綁 0.0.0.0 且 /api 無憑證」。
我先去看程式碼,結果比我以為的好很多:auth_gate::require_cluster_auth_local_ui
是一個設計得相當仔細的閘門——它擋跨來源的網頁、用真實 socket 位址判斷 loopback
(不是可偽造的標頭)、遠端要 HMAC。既有測試裡甚至把陷阱寫在註解:
用
127.0.0.1當測試位址會拿到 loopback 豁免,於是不管閘門在不在都會綠。
寫得很好。問題是它逐 handler 呼叫。
於是我早上加的那 16 個 handler——一個都沒呼叫它。
18 of 18 command-table routes answered an unauthenticated remote caller:
/api/life/note -> 200 OK
/api/life/capture -> 200 OK
...
POST /api/life/note 回 200。同網段任何人都能往我的生活儲存區寫入事件。
我今天上午才寫了一整段,講「讓 app 搆得到生活軌道,才會從『我可以去終端機做』
變成『我會做』」。順手也讓隔壁的人搆得到了。
逐 handler 的閘門,意思是新 handler 預設不驗證。要靠人記得。
而這張表的整個存在理由,就是「不准靠人記得」。
所以修法不是在 16 個 handler 各加一行,是把閘門做成涵蓋整份 routes() 的一層:
let table = routes()
.into_iter()
.fold(Router::new(), |acc, (name, method)| acc.route(&canonical_path(name), method))
.route_layer(from_fn_with_state(state, gate));
明天新增的路由因為在清單裡而被保護,不是因為有人想起某個慣例。
刻意建成獨立的 sub-router 再 merge——route_layer 只蓋這些路徑,
不會誤蓋 serve.rs 裡已經有自己(形狀不同)檢查的手寫路由。
本機完全不受影響:loopback 維持一致的豁免,實測 16 條 GET 加 POST 從
127.0.0.1 全部 200。
去看綁定到底是誰決定的:
~/.spectyn-mesh/agents.toml host = "127.0.0.1"
launchd plist --host 0.0.0.0
我自己的設定檔說只綁本機。是安裝的 launchd 單元用命令列參數推翻了它。
而且:沒有設定任何 peers、沒裝 tailscale、7878 上沒有任何已建立的遠端連線。
今天沒有任何東西依賴那個 LAN 綁定。
程式碼裡甚至有一行註解寫著預設值該是什麼:
Secure-by-default:
"auto"binds loopback + tailnet only (not raw LAN).
安全的預設值一直都在。是部署把它蓋掉的。
而這件事——一個和程式碼無關、只存在於一份 plist 裡的決定——
是今天所有安全問題裡影響最大、也最容易修的一個。
它也解釋了為什麼我前面二十條的曝光「感覺沒那麼嚴重」:
在正確的綁定下,那二十條根本不會被外面碰到。
但那不是它們安全,只是門暫時關著。
--help 會執行命令修完閘門,我順手測了佇列尾端那兩個小問題,結果撞到一個比它們嚴重得多的:
$ spectyn note "這句不該被寫進去" --help
✓ 已記錄筆記(aac13b88…)
$ spectyn focus start --minutes 25 --help
● 專注開始 — 25 分鐘 · session 01a040ff
你打 --help 想知道一個命令做什麼,它就做給你看。
根因是同一個,深一層:note 只檢查 args[2],所以 note --help 印說明,note "text" --help 直接穿過去寫入。跟我當天上午在頂層修的一模一樣。
我沒有去檢查說明輸出對不對。我測的是真正重要而且好陳述的性質:
帶
--help跑完,資料根必須逐 byte 相同。
然後配一條負控制——同一條探針拿掉 --help 必須真的改變狀態,
否則它偵測不到它存在要偵測的 bug。
負控制當場就抓到 event capture 是無效探針:它根本不寫本機,
而是 POST 到 daemon。

event capture 走 HTTP 到 127.0.0.1:7878,所以它忽略 SPECTYN_HOME。
我的測試探針寫進的是真實的事件庫。
我去看,八筆事件裡七筆是我的測試垃圾:
941a69ec [text] analysis skipped: no LLM provider configured
34cf2fa1 [text] probe
61ec75a5 [text] probe
...
3b701ef9 [text] S0 驗收:封存舊資料根、修好空轉哨兵、重建事件儲存區 ← 只有這筆是真的
那些 probe 不是 event capture 寫的,是我的閘門測試寫的。
它 in-process 呼叫真的 handler,handler 從環境解析資料根,
而 cargo test 不設 SPECTYN_HOME —— 於是在閘門還沒加上去、路由還回 200 的
那段時間,它把四筆 "probe" 寫進了真實的生活紀錄。
一個 in-process 測試碰到真實檔案系統,不管它讀起來多乾淨,都不是隔離的。
七筆逐一刪除,測試改用 env_lock + SPECTYN_HOME 隔離(照 auth.rs 既有做法),event capture 從探針清單移除並寫明理由。

跑 golden 的時候它直接爆了:
scripts/golden-cli.sh: line 163: want?: unbound variable
那行是:
echo "✗ spectyn $c --help(退出碼):快照 $want,實際 $rc"
$want 後面直接接全形逗號。shell 把多位元組字元一起吃進變數名,set -u 於是說「未綁定」。
而它之前每次都全綠,所以這一行從來沒有被執行過。
也就是說:golden 報告「退出碼不符」的能力,一直是壞的,而沒有人知道——
包括我,我還在文章裡誇過它。同樣的寫法另有一處,一併修了。
順帶校正它檔頭一句過期的註解(「14 個子命令的 --help 不回 0」——實測 43 個全部回 0)。
一句沒人回去改的註解,跟它警告的漂移是同一種東西。
codex 那份回答很長,我先挑了最重的一條(bind 政策)。剩下的裡有三項值得做:
一、CORS preflight 會不會被我的閘門擋掉。
這是潛在的功能回歸——瀏覽器在真正請求前會先送一個不帶憑證的 OPTIONS。
如果閘門在那裡回 401,合法的遠端 UI 會在還沒機會驗證之前就失敗,
而症狀(console 裡一個 CORS 錯誤)指向的地方離原因很遠。
實測沒有:preflight 回 200,閘門沒攔。
但我第一版的斷言寫成 != 401——那連 405 都會滿足,證明不了任何事。
改成 == 200。
二、to_bytes 的 Err 不一定是「太大」。
我一律回 413。底層讀取失敗(傳輸中斷、解碼錯誤)也被報成「body 太大」,
等於叫呼叫端把一個從來就不大的 body 改小。改成看 Content-Length 分流。
三、/rpc/* 完全不在這層裡,而且從來沒有人盤點過。
這條最刺。我寫了「命令表的 18 條路由全部上閘門」,也量了 /api 的 30 條——
但「我們把 API 上閘門了」是一個關於單一前綴的說法,另一個前綴就在旁邊,沒被量過。
把盤點擴到 /rpc:
routes probed : 55
gated (401/403) : 33
answered without credentials: 22
/rpc 的 25 條裡有 23 條是有閘門的——比 /api 好得多。開著的兩條是/rpc/ping(活性探測,可議)和 /rpc/peers(交出 peer 清單,
跟我在 mesh 組用去指紋關掉的是同一種偵察洩漏)。
兩條併進債的清單,所以棘輪和「拒絕綁非 loopback」的政策一起涵蓋它們。
回頭數,同一個形狀今天出現了五次:
| 看起來 | 實際上 | |
|---|---|---|
capture --help |
CLI 在回答你 | 它在猜,而且收費 |
| 我的驗證腳本 | 修正沒生效 | 量測壞了 |
| golden master | 行為變了 | 是我用了那個工具 |
note --help |
在說明 | 在寫入 |
| golden 的失敗路徑 | 一直全綠 | 那一行從來沒被執行過 |
加上前一天 S0 那條「回顧說今天沒事,其實是儲存區不見了」,是六次。
訊號本身在說謊,而每一次它都長得像好消息。
這已經不是一串巧合了。所以今天加的每一條檢查,我都先把它弄紅一次——
不是儀式,是因為一條沒被證明會紅的檢查,跟一條不存在的檢查,
在綠燈上看起來完全一樣。
佇列清空之後進下一階段:一台全新機器裝得起來、而且拿得到第一個回應。
我先去讀 E007-release-smoke.md——那是這個專案自己寫的煙測 runbook。
它的前置條件第二條寫著:
不要對操作者的 live
~/.spectyn-mesh做煙測(D24 教訓)。SPECTYN_HOME是規格上的資料根覆寫,但尚未被一致地遵守(issue #322)——
不要只靠它做隔離。
那正是我今天早上踩的坑。
知識早就寫在這裡了。它甚至有編號。只是程式碼沒有強制它,
所以四筆測試事件寫進了我的真實生活紀錄。

第一次我 grep dirs::home_dir() 加 grep -v test,得到 66 處。
那只濾掉「含 test 字樣的行」,不是排除測試模組。
第二次寫了計數器排除 #[cfg(test)],得到 24 處。
但它數的是文字出現次數——skill_wire.rs 光是解釋「為什麼這裡用它」
就有兩段文件註解,全被算進去了。
第三次排除註解行,得到 16 處。這才是真的。
而 16 是可以在一個上午還完的規模。前兩個數字會讓人覺得這是個大工程,
然後往後排。量錯的規模,跟量錯的方向一樣會害人。
| 類別 | 處數 | 做法 |
|---|---|---|
| 真的資料根 | 2 | config.rs(agents.toml ← 裡面有金鑰)、evolve_goals.rs(目標佇列)。改走解析函式 |
| 正當的 OS home | 12 | LaunchAgents/systemd unit 路徑、shell 的 ~ 展開、終端機預設 cwd、走訪目錄的邊界 |
第二類不該跟隨 SPECTYN_HOME——那是資料根,不是家目錄。
但它們仍然改了:裸的 dirs::home_dir() 在 Windows 上會忽略 $HOME,
那會同時弄壞測試和設了環境變數的 service manager。
語意不動,機制改對。
一、我早上那個 golden 修正只做了一半。
我讓它跑在一次性資料根上,寫進文章,還誇了它。
但設定仍然洩漏——快照裡記著我真實的四個 provider:gemini, groq, local-ollama, opencode。
輸出看起來完全合理,所以我沒有再看第二眼。
二、spectyn doctor --help 根本沒印說明,它直接跑診斷。
而我那條「已派工的命令 --help 要退出 0」的斷言之所以是綠的,
正是因為隔離壞掉——hermetic 的測試其實讀到了我的真實設定、
找到四個 provider、於是退出 0。
修好一個洩漏,才看見另一個。
還完債之後我寫了一條測試:跑一批煙測會跑的命令,
操作者的真實資料根必須逐檔雜湊完全相同。
依母規則去證明它會紅——把 note 的路徑退回繞過解析函式的寫法。
測試通過了。 兩次。
第三次我插了樁:
[PROBE-BROKEN] note data dir = /Users/markl/.spectyn-mesh
✓ 已記錄筆記(97243f36… · 已加密)
真實根 1 → 2
破壞是有效的。問題在前兩次那個破壞根本沒進到執行檔——cargo test 沒有在跑測試前重建 binary,我測的是舊的那顆。
我為了證明一條測試會紅,做了兩次無效的示範,而兩次都看起來像成功。
這是今天第三次「紅示範本身是壞的」(前兩次:附加在 #[cfg(test)] 之後、
去指紋守衛遍歷自己的清單)。
修好之後測試精確指出多了哪兩個檔。示範洩漏的兩筆事件已刪除。
這一點寫進了測試的文件:證明它有效就得跑一次壞的建置,
而那一跑會先寫進去才觸發斷言。
如果它哪天真的紅了,預期要手動清理那次跑寫進去的東西。
原文說「不要只靠 SPECTYN_HOME」。現在 CLI 端可以了。
但我沒有把那段刪掉改成「現在安全了」——我把剩下的例外寫出來:
spectyn event capturePOST 到127.0.0.1:$SPECTYN_PORT的 daemon
而不是寫本機,所以SPECTYN_HOME隔離不了它——事件會落在
跑著的 daemon 擁有的那個庫。煙測前要把SPECTYN_PORT
指向一次性的 serve,或先停掉 daemon。
一句「現在安全了」會讓下一個人踩我今天早上踩的坑。
部分修好的東西,要說清楚修好的是哪一部分。
隔離修好之後,才輪到打包。scripts/build-mac.sh 本來就寫得很仔細——
它知道 cp 會剝掉簽章,然後 amfid 會靜默 SIGKILL,所以它在來源路徑就簽好、
再用 5 秒的 launch smoke 驗一次才交付。
✓ dist/spectyn-aarch64-apple-darwin (34M)
然後用它 + 全新資料根,量 S2 的出口條件:一台全新機器拿不拿得到第一個回應。
拿到了。27 秒。前一天 S0 修的本機模型 fallback 有效:
提示:沒有 agents.toml — 改用偵測到的本機模型(ollama / qwen2.5-coder:7b)。
而那個回應在我的 repo 裡寫了一個檔案。
[done] file_write → Written 43 bytes to .../src/main.rs
{"name": "git_add", "arguments": {"path": ".../src/main.rs"}}
我下的提示是「只回三個字:你好世界」。本機模型決定寫一個 Rust 檔然後 git add。src/ 整個目錄是它憑空建的——這個 repo 的程式碼在 core/src。

S2 的出口條件寫的是「全新機器拿到第一個回應」。
條件滿足了。 而它遠遠不夠。
一個滿足「拿到回應」但會在你所在目錄寫檔案的冷啟,比拿不到回應更糟。
拿不到回應你會知道,寫了檔案你不會。
這件事讓我對「可量的出口條件」這條鐵律有了一點修正:
可量不等於量對了東西。一個數字能通過,不代表它問的是對的問題。
同一次執行裡 doctor 就說了:
✓ project trust: this directory is untrusted — enforcement off (informational)
打勾。因為在它的定義裡,「enforcement off」是一個正常狀態。
pub enum TrustPolicy {
/// No enforcement — trust is observed/reported but never restricts (default).
#[default]
Off,
Prompt, // 未信任目錄:Allow 降級成 Ask
Observe, // 未信任目錄:夾到唯讀
}
機制蓋好了。而 project_trust.rs 的模組檔頭自己描述了這個威脅:
如果你
cd進一個你不是自己建立的目錄——剛 clone 的 repo、下載的專案、
別人傳給你的資料夾——那個目錄可以用兩種方式攻擊你:設定劫持、以及 prompt injection。
然後預設是 Off。
我去查了 BIG-GOAL.md、OPERATING-STANDARD.md、docs/specs/——
沒有任何地方把「預設關閉」記成一個刻意的取捨。
它不是被決定的,它只是沒有被決定。
Off → Prompt 會讓每一個現有使用者、每進一個新目錄都被問一次。
那是別人的日常,不是我的技術判斷可以代替的。
所以我停下來問,然後照裁決做:只改「完全沒有設定檔」那條路。
pub(crate) fn resolve_trust_policy(configured: Option<&str>, any_config: bool) -> TrustPolicy {
if let Some(p) = configured.and_then(TrustPolicy::from_slug) { return p; }
if any_config { return TrustPolicy::default(); } // 有設定檔 → 完全不變
TrustPolicy::Observe // 冷啟 → 夾到唯讀
}
有 agents.toml 的機器完全不變,包括明文設成 off 的。
改的是冷啟,不是每個人的日常。
[denied] untrusted project (trust enforcement=observe): 'web_search' blocked
— run `spectyn project trust add` to allow it here
→ exit 0 · 目錄檔案數 0 → 0
仍然拿得到回應,零檔案寫入,而且訊息直接告訴使用者怎麼放行。
我另外配了一條測試,釘住 Observe 仍然可用:讀取通過,
寫入/建置/raw-exec/網路擋掉。
因為一個連讀都擋的「唯讀」會讓全新安裝變得沒用,然後被使用者關掉——
那比沒有這道防線更糟,因為它還騙你以為有。
web_search 屬於 NETWORK_TOOLS,所以在未信任目錄被擋。
對一個可能帶著 prompt injection 的目錄來說,擋掉對外送出是合理的——
那正是外洩的管道。但它確實讓冷啟的能力變窄了。
要不要在 Observe 底下細分「唯讀的網路查詢」和「會送出資料的網路動作」,
我留待日後,並且把這個取捨寫下來了。沒有處理的事情要留下名字。
冷啟的信任修好之後,我把 diff 丟給 codex。它的總判斷是
「diff 忠實實作了裁決,沒有寫歪」——然後指出裁決本身有個洞。
沒有 agents.toml → Observe
建立任意 agents.toml → Off
只要 onboarding、設定模型、或設一把 API key 順手寫了一份沒有[trust].enforcement 的設定檔,下一次執行就無聲地從唯讀變回完全開放。
它的原話:
使用者沒有做任何信任決定,權限卻改了。
安全改善只保證第一次呼叫。
我沒想到。而且這不是實作 bug——是我把安全邊界綁在「檔案存不存在」,
於是造出一個很難解釋的懸崖。
裁決是「不改有設定檔的機器」。改那條線是使用者的決定,不是複檢的,
也不是我的。
所以我改的是讓它不再無聲:
⚠ project trust: enforcement is OFF and this directory is untrusted —
tools may write and run here. Nobody chose this; [trust].enforcement is
unset in agents.toml. Set it to `observe` or `prompt`, or
`spectyn project trust add` to trust this directory.
「Nobody chose this」那句是刻意的。它跟「你設定成關閉」是兩件完全不同的事,
而在這之前它們長得一模一樣。
一次啟動一次,不是每次呼叫——沒有人能行動的警告會變成雜訊,
然後跟沒有警告一樣。
一、錯誤訊息只給了一條路。
被擋的時候訊息是「run spectyn project trust add」——
那是最省事、也最全面的解法。使用者會直接複製。
codex 建議分級:單次批准 → 只信任目前的 canonical project root →
維持 observe → 最後才是全域關閉,並清楚標示風險。
它說得很白:「使用者往往會複製最省事的全域 off。」
一道防線如果只提供「整個關掉」這個出口,那它遲早會被整個關掉。
二、Observe 底下應該細分網路動作。
完全不限制網路不安全——模型可以把剛讀到的本機內容塞進 query 送出去,
那正是外洩管道。但把網路查詢和寫檔一起說成「唯讀限制」也不合理,
而且 blanket deny 很傷首次體驗。
兩件都寫進了發現文件。沒有處理的事情要留下名字。

DATA_DIR="$HOME/.spectyn-mesh"
#322 我掃了 Rust,沒掃 shell。
所以 SPECTYN_HOME=/tmp/x ./install.sh 會在錯的地方建目錄,
而真正被用的那個根一動也沒動。
改成 ${SPECTYN_HOME:-$HOME/.spectyn-mesh},實測資料根現在建對地方。
一次「還清了」的宣告,範圍要講清楚。我說的是 Rust 那 16 處,
不是所有會拼路徑的地方。
我原本懷疑 install -m 0755 複製執行檔會剝掉 macOS 的簽章,
然後 amfid 靜默 SIGKILL——build-mac.sh 的檔頭就是為了這個才在
來源路徑先簽好。
實測沒有成真:cargo 在 Apple Silicon 上會自動 ad-hoc 簽章,
裝好的執行檔 --version 正常 exit 0。
寫下來是因為——一個查過而且沒問題的疑慮,跟一個沒查過的疑慮,
在文件上看起來一樣。 下一個人不該再查一次。
runbook 的 Step 0 把這個列為主要安裝路徑:
curl -fsSL https://spectynmesh.io/install.sh | sh
而 install.sh 自己第 11 行寫著:
(curl .../install.sh | sh) is a FUTURE release-pipeline item and is not wired
兩份文件在同一個倉庫裡,一份說這是主要路徑,一份說它不存在。
這個需要主機端,不是我今天能修的。但它值得一個名字,
因為煙測 runbook 照著跑會在 Step 0 就卡住——
而那正是「一台全新機器」最先碰到的一步。
一整天,每次跑完全套測試我都要多做一件事:把失敗名單跟 HEAD 比對,
確認「這 3 個(有時 4 個、有時 5 個)是已知浮動,不是我弄壞的」。
一個隨機失敗的測試套件,本身就是會說謊的訊號——而我整天都在為它付利息。
所以下午終於去看。
連跑五次 skillbank,失敗名單:
第 1 次: search_matches_inserted_token, ingest_batch_...
第 2 次: search_matches_inserted_token, ingest_batch_...
第 3 次: insert_seals_text..., search_matches_inserted_token
第 4 次: delete_sealed_row..., search_matches..., ingest_batch_...
第 5 次: delete_sealed_row..., search_matches..., search_with_escaped..., ingest_batch_...
名單每次不同——但 search_matches_inserted_token 每次都在。
單獨跑它:過。--test-threads=1 跑整個模組:54/54 全過。
不是邏輯 bug。是平行競爭。

共享的是 SPECTYN_ENCRYPT_MEMORY(行程全域的環境變數)和EVENT_KEY_CACHE(行程全域的金鑰快取)。而守著它們的是:
| 模組 | 鎖 |
|---|---|
skillbank::memory |
自己的 SEAL_TEST_LOCK |
skillbank::memory_seal |
自己的另一把 LOCK |
skillbank::sync |
crate 層級的 env_lock |
三個模組,三把不同的鎖,守同一個全域。它們彼此完全無法互斥——
只能排除自己人。
而且第一把還有第二層問題:註解寫著「every flag-ON test serializes
on this mutex」——只有寫入方拿鎖,讀取方不拿。單邊拿的 mutex
不提供任何互斥:一條 flag-OFF 的測試可以在別人把 env 設成 ON 的時候跑,
它的插入被加密了,搜尋自然找不到。
修法乏味而正確:全部統一到 crate::env_lock,讀者跟寫者一樣拿。
skillbank 安靜下來之後,全套還是偶爾紅——這次是 identity_wire 的
兩條 keychain 測試,MasterNotFound:自己剛寫進去的,立刻讀不回來。
帳號名有 pid + uuid,不可能撞名。單獨跑 4/4 全過。
變因是對 macOS Security framework 的並行存取。序列化之後消失。
它一直躲在 skillbank 的雜訊後面。一個浮動遮住另一個——
這就是「known flaky」這個標籤昂貴的原因:它讓人不再去看。
修完之後,全套連跑 17 次:16 綠,1 次失敗(那一次沒留下名字)。
從「每次 3–5 個失敗、名單天天換」到 16/17。量級改善——
但我不寫「根除」。殘留一個低頻競爭,等它再現時,至少現在的套件
安靜得足以讓它有名字。
--help 的最後兩個一整天下來,--help 被錯待的形式收集了七種。最後兩個:
spectyn doctor --help 跑了診斷。
「doctor 是做什麼的」這個問題,得到的回答是一個描述機器健康的退出碼——
在全新機器上,那個答案是 2。
而它的能見度本身有個故事:這個缺陷是 SPECTYN_HOME 隔離修好之後才看得見的。
在那之前,hermetic 測試讀到我的真實設定、找到四個 provider、退出 0——
測試綠,是因為隔離壞掉。修好一層,才看見下一層。
spectyn -q --help 開了互動 session。
根層的 help 檢查只比對 args[1]。旗標開頭的形式落空,掉進 session——
「要說明」變成「啟動一個 UI」。
最有意思的是,處理這件事的那段守衛,註解裡記著一次真實事故:
a real incident:
logout --helponce wiped the operator's auth.json
名單早就在了。 有人被咬過,寫了守衛,列了十二個危險的子命令。
只是「旗標開頭」這個形狀不在名單上——名單永遠會少一個形狀。
這正是 Q1 那條「與名單無關」規則存在的理由,今天在另一層又證明了一次。

capture --help → 燒一次 LLM 呼叫 (Q1)
banana --help → 燒一次 LLM 呼叫 (Q1)
note "x" --help → 寫入一筆事件 (help_never_mutates)
focus start --help → 開始 25 分鐘計時 (help_never_mutates)
event capture --help → 寫進 daemon 的事件庫 (help_never_mutates)
doctor --help → 跑診斷,exit 2 (今天最後修)
-q --help → 開互動 session (今天最後修)
「這是什麼」永遠不可以變成「做這件事」。
一句話的不變量,七個藏身處。每一個都有一條會紅的測試看著了。

測試套件安靜下來之後,回頭處理懸案。紅測試釘著的數字:
一個 turn,死 provider 被接觸 75 次,花 75 秒。
兩天前我插樁過 call_with_streaming 和 call_with_fallback,零輸出——
於是記成「不是這兩條,存在第三條迴圈」,並且照這個結論去讀了很多程式碼。
今天不讀碼了,直接三點插樁(env-gated),跑紅測試:
25 [LOOP-PROBE] call_with_fallback entered
DEAD ENDPOINT CONTACTED 75 TIMES
就是 call_with_fallback。進入 25 次。
前一輪的「零輸出」十之八九是插樁根本沒進到執行檔——
跟今天稍早紅示範沒 rebuild 是同一種失誤。我根據一次壞掉的量測,
記下了一個錯的結論,然後帶著它讀了兩天的程式碼。
放大器不是重試,是回合:run_inner 每個工具回合都重走整條 provider 鏈。
上一回合才確認死掉的 provider,下一回合又從頭試。
25 回合 × 3 次網路重試 = 75。
兩天前試過一次全域斷路器——別的測試的死端點把「開路」狀態
漏給下一個測試,不相干的 skill_wire 因此多紅一個,整個 revert。
這次綁在 AgentRuntime 上:runtime 死,狀態跟著死,測試各自 hermetic。
接線刻意最小:兩個 provider 迴圈頭諮詢 allow(),
generic HTTP 路徑三點記錄(網路錯誤與 429/503 計入開路、2xx 重置)。
訂閱 CLI 路徑和 serve/TUI 的第三條迴圈還沒記錄——寫進佇列,不順手。
修前:DEAD ENDPOINT CONTACTED 75 TIMES (75 秒)
修後:DEAD ENDPOINT CONTACTED 3 TIMES (3 秒)
threshold 3 開路,其餘 24 回合零接觸。
紅測試的 ignore 字串自己寫著「解除 ignore 就是完工定義」——解除了,
它現在是常駐守衛。反向母規則:把諮詢改成 if false,立刻回到 75 次並紅。
這一段最值得留的不是修法,是那個帶著錯誤結論讀了兩天程式碼的教訓:
量測工具自己要先被驗證。一次「零輸出」在說的可能不是「這裡沒有」,
而是「你的探針沒裝上」。
早上 S0 驗收時,我的真實每日回顧長這樣:
### Daily alignment
{"name": "alignment reflection", "parameters": {"input": " quiet day no interactions…"}}
本機模型對「溫暖地反思今天」這個提示,回了一個捏造的工具呼叫。
有趣的是防線的分工:salvage_tool_call_from_text(Day 2 加的)正確地
沒有執行它——名字不在註冊表。但沒有任何東西阻止這串 JSON 被當成
散文呈現出來。
而反思是整個系統裡唯一一個輸出被人當成溫度來讀的地方。
一坨 JSON 出現在那裡,比誠實的跳過更糟。
修法三句話:判斷輸出是不是 tool-call 形狀(刻意與註冊表無關——
不管模型把工具叫什麼,散文它都不是)、是就走既有的 (skipped: …) 降級、
不執行不呈現不捏造。
測試用的就是今早那串真實字串,加三句散文反例——包括談到 JSON 的
和含大括號的,因為誤殺真散文的守衛會被關掉。
上一段 commit 我寫「serve/TUI 用 streaming.rs 的第三條迴圈,記錄未接」。
查證後是錯的:serve 與 TUI 全部走 AgentRuntime::run → 已被 breaker
涵蓋。streaming.rs 那條 'rounds 自由函式迴圈只有測試在呼叫——
它是這棵樹的第四條死鷹架(前三條:providers_wire、其 circuit_breaker
接線、is_served 的舊名單)。
死鷹架的可怕不在它占空間,在它長得像活的:我兩天前就是把斷路器
接進了其中一條,綠了,而正式碼一次都沒走過它。

下午的指令改成「持續邊重建邊紀錄,直到全數功能跟介面正常」。
最直接的量法:跑產品自帶的 spectyn selftest。
第一次:69 pass / 16 fail。逐一驗完,16 個失敗分成五種病:
我帶著 SPECTYN_HOME 跑 selftest——它洩給每個場景和 cargo test 子行程,
把場景自建的 HOME= 隔離全部蓋掉:43 個單元測試、三整組功能因此紅,
而功能全是好的。selftest 現在會 shed 掉外來的覆寫(它有自己的隔離策略),
而那行 note 放在參數解析之後——因為 golden 立刻抓到 --help 多了一行:
說明路徑不准長出副作用,今天自己定的規則自己差點破。
這台機器其實有 tailscale 位址(app 在跑、CLI 不在 PATH——我早上
「未安裝」的結論只對一半)。host=auto 解析出 tailnet 位址,被「還有
無憑證路由就拒綁非 loopback」擋下,serve 開機即死。政策不變;場景改成
明確 --host 127.0.0.1(它們本來就只打 localhost),拒絕訊息補上引導。
autoevolve --dry-run:這個旗標從來不存在,場景一直紅得像功能壞掉spectyn peer assign),LANG=C
run 場景 exit 127 = macOS 沒有 timeout。30-mcp.sh 早就解過一次,
解法留在單一場景沒共用。提升成 selftest.sh 的 t_timeout 並 export——
沒 export 時場景子行程照樣 127,量過。
function_name/parameters 別名:實測 qwen2.5-coderSPECTYN_PORT 讀寫不對稱:help 說它「覆寫 serve 的 :7878」,serve 認,detect_listen_port() 不認——用它換 port 的 daemon,同機的spectyn sessions 反而找不到。這是從一條測試讀到我 24 個真實 session69 pass / 16 fail / 3 skip → 86 pass / 1 fail / 4 skip
最後一項待終驗。skip 都是誠實的(無 hf CLI、模型不配合、快照工具缺)。
summary 87 pass, 0 fail, 4 skip (658s)
selftest 首次全綠。 最後兩片:launchd 的時間斷言是 Linux 形狀的期望
(override 在 mac 上有效,只是 plist 把 04:30 拆成 Hour/Minute 整數,
字面比對紅在格式而不是行為);以及失敗計數的 grep 把 test result: FAILED.
摘要行也數進去,每個失敗 target 虛高一。
從 69/16 到 87/0,一個下午。而其中真正的產品缺陷只有三個
(salvage 別名、SPECTYN_PORT 不對稱、bind 政策與 auto 的互動)——
其餘十三個全是量測自己壞了:化石場景、化石斷言、洩漏的隔離、
缺 export 的 shim、虛高的計數。
這大概就是今天最後的一課:一個說「16 個功能壞了」的儀表板,
實際上是 3 個功能問題加 13 個儀表板問題。
修儀表板跟修功能一樣是工程,而且往往得先做。
我把這批收斂丟給 codex,連同我的分類:「13 個量測問題、3 個產品缺陷」。
它的回覆值得整段留下:
「13/3 這個精確說法有粉飾風險。
--help因 shed note 多出副作用輸出,
是使用者可見的 CLI 行為缺陷——即使最先由 brittle assertion 發現。
測試程序竟能讀到操作者 24 個真 session,也不應只寫成『假紅』便結案。
『化石場景』只有在舊場景確實已被新契約取代時才是量測問題——
如果只是產品不再滿足舊需求,然後順手把測試改掉,那是在藏回歸。」
它說得對。更誠實的分類是:
| 類別 | 數量 |
|---|---|
| 核心功能缺陷(salvage 別名、SPECTYN_PORT 不對稱、bind×auto) | 3 |
| 使用者可見的 CLI 行為缺陷(--help 副作用行) | ≥1 |
| 測試契約/隔離缺陷(洩漏的隔離、缺 export 的 shim) | 若干 |
| 純報表缺陷(虛高計數) | 1 |
「3」那個數字太乾淨了。乾淨的數字最好聞一下。
三個具體收緊也照做了:file_read 拆成「確定性介面 probe(硬斷言,nonce
fixture)+ 模型選工具(標 INCONCLUSIVE)」——拆完第一跑就有收穫:
fixture 放錯位置被工具沙盒正確拒絕,紅在一個在做事的沙盒上;
launchd 斷言收成同一 dict 內的相鄰結構;dashboard 斷言補上唯一性與前綴。
順帶第八個發現:spectyn tool 讀檔失敗印 Error 卻 exit 0。進佇列。
含今天全部修正的最終全套:
summary 95 pass, 1 fail, 3 skip (524s)
那 1 個 fail:FTS5 的 p99 延遲測試,在我旁邊並行編譯的負載下超了
200ms;單獨重跑 12 秒綠。效能斷言在共享負載下的假紅——不是退化。
而它的報告寫著「0 test(s) failed (exit 101)」——昨天修虛高計數的
那刀,字元類別漏了數字(測試名裡有 200ms),把計數往 0 偏。
同一天裡,修膨脹的修正造出縮水的。 補上 0-9。
三個 skip 都有名字:hf CLI 未安裝、mobile 衛星未 clone、
7B 模型的 tool-choice 標 INCONCLUSIVE(介面本身由確定性 probe 硬斷言覆蓋)。
從 69/16 到 95/1(且那 1 個無干擾下是綠的)。spectyn tool 的退出碼也修了——失敗印 Error 卻 exit 0 的日子結束,
四種失敗形狀紅測試先行。
今天在此收工。

晚上的指令變成一條完整的驗收鏈:安裝、導入雲端 API、本地模型、
實測、操作者複測、最後 app 端可用。第一段(G1)當晚做完,
它挖出的東西比預期多。
provider.set_key 在命令表裡 cli: true 掛了三天;doctor 的修復提示
甚至叫使用者跑 spectyn provider add——兩者都不存在。
把 key 弄進一個全新機器的路,只有瀏覽器精靈或手編 toml。
艦隊不可能經瀏覽器導入。
新面一條:
spectyn provider set-key <name> --env VAR # 只記變數名
echo "$KEY" | spectyn provider set-key <name> --key-stdin --model M
--key <值> 直接拒絕——argv 對整台機器的每個行程可見,還進 shell history。
值不回顯;toml 原地編輯,別的段落逐 byte 保留。

一、我寫了 loader 不認的鍵。 ProviderEntry 是#[serde(rename = "type")],我寫 provider_type——providers list 對著一個明明寫著型別的檔案顯示 <no type>。
結構體的欄位名不是線上格式的鍵名。
二、部分設定比零設定更糟。 零設定的機器,exec 會合成 local-first
agent、能答;set-key 寫入一個 provider 之後,同一台機器反而死在No agent configuration found。加了一把 key,機器變得更不能回答了。
修法:有 providers 無 [agent] 時合成預設,走既有解析鏈。
三、doctor 指路指向不存在的指令(見上)。
四、你日常設定裡的 gemini 其實早就壞了——gemini-2.0-flash 已退役,
404。沒有任何東西告訴過你。不動真實設定,記下來。
全新根、真 key 程序內轉移(不落 argv、不印出):
groq → HTTP 403 "check your network settings" 請求抵達 Groq,網路層拒
gemini → model 修正後 429 rate limit 連 401 都沒有:key 有效,額度滿
鏈完整證明(導入→設定→解析→真請求→上游明確回應);
「真實 200」被上游額度與網路擋住——那是環境事實,不是 CLI 的。
三家證據俱附,G1 以此收。
(Day 04 實錄至此完整收錄;工程檔案與測試見 repo。)